# -*- coding: utf-8 -*-

#
# peerscape/ftp/__init__.py
#
# Copyright 2009 Helsinki Institute for Information Technology
# and the authors.
#
# Authors:
#       Konrad Markus <konrad.markus@hiit.fi>
#

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import sys
import traceback
import os, posixpath, errno
import asyncore
import logging
import time, datetime
import StringIO
import re

from peerscape import DB
from pyftpdlib import ftpserver
        

class DBFile(object):
    """A class which provides a file-like interface for peerscape db entries"""

    def __init__(self, db, iid, dspath, name, mode): 
        self.db = db
        self.iid = iid
        self.dspath = dspath
        self.mode = mode
        self.name = name
        if self.mode == 'w' or self.mode == 'wb':
            self.rep = StringIO.StringIO()
        else:
            self.rep = StringIO.StringIO(self.db.get(iid, dspath))
        self.closed = False

    def __getattr__(self, attr):
        return getattr(self.rep, attr)

    def close(self):
        if self.mode == 'w' or self.mode == 'wb':
            try:
                self.db.put(self.iid, self.dspath, self.getvalue())
            except Exception:
                logging.exception('ftp: error executing action.')
                raise IOError(errno.EPERM, "Permission denied")

        self.rep.close()
        self.closed = True


class DBAuthorizer(ftpserver.DummyAuthorizer):
    # allow anonymous to have write permisisons without raising a warning
    def _check_permissions(self, username, perm):
        warned = 0
        for p in perm:
            if p not in 'elradfmw':
                raise AuthorizerError('No such permission "%s"' %p)


class DBDTPHandler(ftpserver.DTPHandler):
    def handle_close(self):
        """Called when the socket is closed."""
        # If we used channel for receiving we assume that transfer is
        # finished when client close connection , if we used channel
        # for sending we have to check that all data has been sent
        # (responding with 226) or not (responding with 426).
        # closing the file handle can throw an exception due to permissions; respond accordingly
        if self.receive:
            self.transfer_finished = True
            action = 'received'
        else:
            action = 'sent'
        if self.transfer_finished:
            if self.file_obj is not None and not self.file_obj.closed:
                try:
                    self.file_obj.close()
                except IOError:
                    self.cmd_channel.respond("550 Permission denied.")
                else:
                    self.cmd_channel.respond("226 Transfer complete.")
                    if self.file_obj:
                        fname = self.cmd_channel.fs.fs2ftp(self.file_obj.name)
                        self.cmd_channel.log('"%s" %s.' %(fname, action))
            else:
                self.cmd_channel.respond("226 Transfer complete.")
                if self.file_obj:
                    fname = self.cmd_channel.fs.fs2ftp(self.file_obj.name)
                    self.cmd_channel.log('"%s" %s.' %(fname, action))
            
        else:
            if self.file_obj is not None and not self.file_obj.closed:
                try:
                    self.file_obj.close()
                except IOError:
                    self.cmd_channel.respond("550 Permission denied.")
                else:
                    tot_bytes = self.get_transmitted_bytes()
                    msg = "Transfer aborted; %d bytes transmitted." %tot_bytes
                    self.cmd_channel.respond("426 " + msg)
                    self.cmd_channel.log(msg)
            else:
                tot_bytes = self.get_transmitted_bytes()
                msg = "Transfer aborted; %d bytes transmitted." %tot_bytes
                self.cmd_channel.respond("426 " + msg)
                self.cmd_channel.log(msg)

        self.close()


    def close(self):
        """Close the data channel, first attempting to close any remaining
        file handles."""
        if not self._closed:
            self._closed = True
            if self.file_obj is not None and not self.file_obj.closed:
                try:
                    self.file_obj.close()
                except:
                    pass

            if self.idler and not self.idler.cancelled:
                self.idler.cancel()
            asyncore.dispatcher.close(self)
            self.cmd_channel.on_dtp_close()


class DBFTPHandler(ftpserver.FTPHandler):
    def ftp_FEAT(self, line):
        """List all new features supported as defined in RFC-2398."""
        features = ['EPRT','EPSV','SIZE']
        s = ''
        for fact in self._available_facts:
            if fact in self._current_facts:
                s += fact + '*;'
            else:
                s += fact + ';'
        features.append('MLST ' + s)
        features.sort()
        self.push("211-Features supported:\r\n")
        self.push("".join([" %s\r\n" %x for x in features]))
        self.respond('211 End FEAT.')


class DBFS(ftpserver.AbstractedFS):
    """A class used to interact with the file system, providing a high
    level, cross-platform interface compatible with both Windows and
    UNIX style filesystems.

    It provides some utility methods and some wraps around operations
    involved in file creation and file system operations like moving
    files or removing directories.

    Instance attributes:
     - (str) root: the user home directory.
     - (str) cwd: the current working directory.
     - (str) rnfr: source file to be renamed.
    """

    def __init__(self, path):
        self.dbpath = path
        self.db = DB(self.dbpath)

        self.root = '/'
        self.cwd = '/'
        self.my_iid = self.db.get_my_iid()
        self.rnfr = None

    # --- Pathname / conversion utilities

    def ftpnorm(self, ftppath):
        """Normalize a "virtual" ftp pathname (tipically the raw string
        coming from client) depending on the current working directory.

        Example (having "/foo" as current working directory):
        'x' -> '/foo/x'

        Note: directory separators are system independent ("/").
        Pathname returned is always absolutized.
        """
        if posixpath.isabs(ftppath):
            p = posixpath.normpath(ftppath)
        else:
            p = posixpath.normpath(posixpath.join(self.cwd, ftppath))
        # normalize string in a standard web-path notation having '/'
        # as separator.
        p = p.replace("\\", "/")
        # posixpath.normpath supports UNC paths (e.g. "//a/b/c") but we
        # don't need them.  In case we get an UNC path we collapse
        # redundant separators appearing at the beginning of the string
        while p[:2] == '//':
            p = p[1:]
        # Anti path traversal: don't trust user input, in the event
        # that self.cwd is not absolute, return "/" as a safety measure.
        # This is for extra protection, maybe not really necessary.
        if not posixpath.isabs(p):
            p = "/"
        return p

    def ftp2fs(self, ftppath):
        """Translate a "virtual" ftp pathname (tipically the raw string
        coming from client) into equivalent absolute "real" filesystem
        pathname.

        Example (having "/home/user" as root directory):
        'x' -> '/home/user/x'

        Note: directory separators are system dependent.
        """
        # as far as I know, it should always be path traversal safe...
        if posixpath.normpath(self.root) == '/' or posixpath.normpath(self.root).endswith(':\\'):
            return posixpath.normpath(self.ftpnorm(ftppath))
        else:
            p = self.ftpnorm(ftppath)[1:]
            return posixpath.normpath(posixpath.join(self.root, p))

    def fs2ftp(self, fspath):
        """Translate a "real" filesystem pathname into equivalent
        absolute "virtual" ftp pathname depending on the user's
        root directory.

        Example (having "/home/user" as root directory):
        '/home/user/x' -> '/x'

        As for ftpnorm, directory separators are system independent
        ("/") and pathname returned is always absolutized.

        On invalid pathnames escaping from user's root directory
        (e.g. "/home" when root is "/home/user") always return "/".
        """

        if fspath.endswith(':\\'):
            return '/'

        if posixpath.isabs(fspath):
            p = posixpath.normpath(fspath)
        else:
            p = posixpath.normpath(posixpath.join(self.root, fspath))
        if not self.validpath(p):
            return '/'
        p = p.replace(os.sep, "/")
        p = p[len(self.root):]
        if not p.startswith('/'):
            p = '/' + p

        return p

    
    def escape_root_name(self, item):
        if item:
            return item.replace('/', '0x2f')

        return item


    def parse_root_name(self, item):
        ret = re.search(r'\(([0-9a-f]*)\)$', item)
        if ret:
            return ret.group(1)

        return item


    def canread(self, path):
        iid, dspath = self.pathtodb(path)

        # Doesn't try to test for read permissions
        return True


    def canwrite(self, path):
        # Doesn't try to test for write permissions. Tries to write on close.
        return True


    def dbtopath(self, iid, dspath):
        """Turn an iid and sataset path into an ftp path"""
        dsname = self.db.get_profile_name(iid)
        dsname = '%s (%s)' % (dsname, iid)

        return posixpath.join('/', dsname, dspath)


    def pathtodb(self, path):
        """Turn a path into a iid, dataset path tuple"""

        path = path[1:]
        path_segs = path.split('/', 1)
        if len(path_segs) == 1:
            iid = self.parse_root_name(path_segs[0])
            dspath = '/'
        else:
            iid = self.parse_root_name(path_segs[0])
            dspath = path_segs[1]

        return (iid, dspath)


    def validpath(self, path):
        return True

    # --- Wrapper methods around open() and tempfile.mkstemp

    def open(self, filename, mode):
        """Open a file returning its handler."""
        iid, dspath = self.pathtodb(filename)
        return DBFile(self.db, iid, dspath, filename, mode)


    def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'):
        """A wrap around tempfile.mkstemp creating a file with a unique
        name.  Unlike mkstemp it returns an object with a file-like
        interface.
        """
        # [FIXME]
        class FileWrapper:
            def __init__(self, fd, name):
                self.file = fd
                self.name = name
            def __getattr__(self, attr):
                return getattr(self.file, attr)

        text = not 'b' in mode
        # max number of tries to find out a unique file name
        tempfile.TMP_MAX = 50
        fd, name = tempfile.mkstemp(suffix, prefix, dir, text=text)
        file = os.fdopen(fd, mode)
        return FileWrapper(file, name)

    # --- Wrapper methods around os.*

    def chdir(self, path):
        """Change the current directory."""
        if self.isdir(path):
            self.cwd = self.ftpnorm(path)
        else:
            raise OSError(errno.ENOENT, "No")
        

    def mkdir(self, path):
        """Create the specified directory."""
        if self.isdir(path):    
            raise OSError(errno.EEXIST, "Directory exists")

        else:
            iid, dspath = self.pathtodb(path)
            try:
                self.db.put(iid, '%s/.dir' % dspath, '')
            except:
                raise OSError(errno.EPERM, "Permission denied")


    def list_root(self):
        ret = self.db.list_datasets()

        ret = ['%s (%s)' % (self.escape_root_name(self.db.get_profile_name(x)), x) for x in ret]
        return [x.encode('utf8') for x in ret]


    def listdir(self, path):
        """List the content of a directory."""
        if path == '/' or path.endswith(':\\'):
            return self.list_root()
        else:
            iid, dspath = self.pathtodb(path)

            listing = self.db.list(iid, dspath)
            listing = [x.split('/')[0].encode('utf8') for x in listing]

            return list(set(listing))

    def rmdir(self, path):
        """Remove the specified directory."""
        if not self.isdir(path):
            raise OSError(errno.ENOTDIR, "Not a directory")

        if not self.isempty(path):
            raise OSError(errno.ENOTEMPTY, "Directory not empty")
            
        else:
            iid, dspath = self.pathtodb(path)
            self.db.delete(iid, '%s/.dir' % dspath)
            

    def remove(self, path):
        """Remove the specified file."""
        if path == "/" or self.isdir(path):
            raise OSError(errno.EPERM, "Cannot remove directory")
            
        else:
            iid, dspath = self.pathtodb(path)
            try:
                self.db.delete(iid, dspath)
            except:
                raise OSError(errno.EPERM, "Permission denied.")


    def rename(self, src, dst):
        """Rename the specified src file to the dst filename."""
        if self.isdir(src):
            raise OSError(errno.EPERM, "Cannot rename a directory")

        else:
            siid, sdspath = self.pathtodb(src)
            diid, ddspath = self.pathtodb(dst)

            if siid != diid:
                raise OSError(errno.EPERM, "Cannot rename across datasets")

            try:
                data = self.db.get(siid, sdspath)
                self.db.delete(siid, sdspath)
                self.db.put(diid, ddspath, data)
            except:
                raise OSError(errno.EPERM, "Permission denied.")


    def stat(self, path):
        """Perform a stat() system call on the given path."""
        # noop
        pass

    def lstat(self, path):
        """Like stat but does not follow symbolic links."""
        # noop
        pass

    if not hasattr(os, 'lstat'):
        lstat = stat

    # --- Wrapper methods around posixpath.*

    def isfile(self, path):
        """Return True if path is a file."""
        return not self.isdir(path)

    def islink(self, path):
        """Return True if path is a symbolic link."""
        return False

    def isempty(self, path):
        """Return True if directory has only a .dir child."""
        if path == '/':
            return False
        else:
            try:
                iid, dspath = self.pathtodb(path)
                if len(self.db.list(iid, dspath)) == 1 and self.db.get_size(iid, '%s/.dir' % dspath) >= 0:
                    return True
                else:
                    return False
            except:
                return False

    def isdir(self, path):
        """Return True if path is a directory."""
        if path == '/':
            return True
        else:
            try:
                iid, dspath = self.pathtodb(path)
                if dspath == '/':
                    return True

                return len(self.db.list(iid, dspath))
            except:
                return False


    def getsize(self, path):
        """Return the size of the specified file in bytes."""

        iid, dspath = self.pathtodb(path)
        return self.db.get_size(iid, dspath)


    def getmtime(self, path):
        """Return the last modified time as a number of seconds since
        the epoch."""
        # [FIXME]
        return time.mktime(datetime.datetime.now().timetuple())


    def realpath(self, path):
        """Return the canonical version of path eliminating any
        symbolic links encountered in the path (if they are
        supported by the operating system).
        """
        return path


    def lexists(self, path):
        """Return True if path refers to an existing path, including
        a broken or circular symbolic link.
        """

        if path == '/':
            return True
        else:
            iid, dspath = self.pathtodb(path)
            return self.isdir(path) or self.db.get(iid, dspath) != None

    # --- Listing utilities

    # note: the following operations are no more blocking

    def get_list_dir(self, path):
        """"Return an iterator object that yields a directory listing
        in a form suitable for LIST command.
        """
        if self.isdir(path):
            listing = self.listdir(path)
            listing.sort()
            return self.format_list(path, listing)
        else:
            basedir, filename = path.split('/')
            return self.format_list(basedir, [filename])

    def format_list(self, basedir, listing, ignore_err=True):
        """Return an iterator object that yields the entries of given
        directory emulating the "/bin/ls -lA" UNIX command output.

         - (str) basedir: the absolute dirname.
         - (list) listing: the names of the entries in basedir
         - (bool) ignore_err: when False raise exception if os.lstat()
         call fails.

        On platforms which do not support the pwd and grp modules (such
        as Windows), ownership is printed as "owner" and "group" as a
        default, and number of hard links is always "1". On UNIX
        systems, the actual owner, group, and number of links are
        printed.

        This is how output appears to client:

        -rw-rw-rw-   1 owner   group    7045120 Sep 02  3:47 music.mp3
        drwxrwxrwx   1 owner   group          0 Aug 31 18:50 e-books
        -rw-rw-rw-   1 owner   group        380 Sep 02  3:40 module.py
        """
        for basename in listing:
            if basename == '.dir':
                continue

            if basedir == '/':
                file = basedir + basename
            else:
                file = '/'.join([basedir, basename])

            if file == '/' or self.isdir(file):
                perms = 'dr-xr-xr-x'
                size = 0
                try:
                    iid, dspath = self.pathtodb(file)
                    ts = self.db.get_json(iid, '/info/modified')
                    if ts:
                        ts = ts['timestamp']
                        # 2008-12-11T12:01:04.928537
                        mtime = time.strftime("%b %d %H:%M", time.strptime(ts[:18], "%Y-%m-%dT%H:%M:%S"))
                    else:
                        mtime = time.strftime("%b %d %H:%M")
                except:
                    mtime = time.strftime("%b %d %H:%M")
            else:
                perms = '-r--r--r--'
                mtime = time.strftime("%b %d %H:%M")
                iid, dspath = self.pathtodb(file)
                try:
                    size = self.db.get_size(iid, dspath)
                except:
                    size = 0

            # number of links to inode
            nlinks = 1
            uname = "owner"
            gname = "group"

            # formatting is matched with proftpd ls output
            yield "%s %3s %-8s %-8s %8s %s %s\r\n" %(perms, nlinks, uname, gname,
                                                     size, mtime, basename)


